// ==UserScript== // @name Better Figma Layer Exporter // @name:zh-CN Better Figma Layer Exporter // @namespace https://github.com/XuQK/Better-Figma-Layer-Exporter // @version 1.1.2 // @license MIT // @description A more convenient Figma layer export solution, featuring the following main functions: 1. Direct export of selected layers as PNGs and automatically assigning them to their corresponding DPI drawable folders; 2. Support for converting PNGs to WebP format before exporting; 3. Support for exporting SVGs optimized through SVGO. // @description:zh-CN 更方便的 Figma 图层导出,主要功能:1. 选定图层直接导出为 png 并按 dpi 分配到对应 dpi 的 drawable 文件夹; 2. 支持将 PNG 转换成 WebP 再导出; 3. 支持导出经 SVGO 优化的 svg 图片。 // @author XuQK // @match https://www.figma.com/* // @icon https://github.com/XuQK/Better-Figma-Layer-Exporter/blob/master/assets/icon.jpeg?raw=true // @grant unsafeWindow // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @require https://cdn.jsdelivr.net/npm/sweetalert2@11 // @connect * // @run-at document-end // ==/UserScript== (function () { "use strict"; const coloredToastStyle = document.createElement("style"); coloredToastStyle.innerHTML = ` .colored-toast.swal2-icon-success { background-color: #a5dc86 !important; } .colored-toast.swal2-icon-error { background-color: #f27474 !important; } .colored-toast.swal2-icon-warning { background-color: #f8bb86 !important; } .colored-toast.swal2-icon-info { background-color: #3fc3ee !important; } .colored-toast.swal2-icon-question { background-color: #87adbd !important; } .colored-toast .swal2-title { color: white; } .colored-toast .swal2-close { color: white; } .colored-toast .swal2-html-container { color: white; } `; document.head.appendChild(coloredToastStyle); GM_registerMenuCommand("Settings/设置", showSettingsDialog, "S"); function showSettingsDialog() { Toast.fire({ title: "Settings / 设置", html: `
Day Night

PS:

1. SVG 优化和 PNG 转 WebP 的需要后台能力,目前是白嫖的 node 服务器,资源有限,请温柔使用~

2. 如果想将此 node 服务器运行在自己本地,参见 https://github.com/XuQK/Better-Figma-Layer-Exporter#扩展功能

`, width: 600, focusConfirm: false, showCancelButton: true, didOpen() { document.getElementById(`kd-mode-${mode}`).checked = true; }, preConfirm: () => { return [ document.getElementById("kd-figma-token").value, document.getElementById("kd-server-svg-optimizer").value, document.getElementById("kd-server-png-convert-to-webp").value, document.getElementById("kd-svg-precision").value, document.getElementById("kd-webp-quality").value, document.querySelector("input[name='kd-mode']:checked").value ]; } }).then(value => { const params = value.value; figmaToken = params[0]; svgOptimizerRequestUrl = params[1]; pngConvertToWebpRequestUrl = params[2]; svgPrecision = params[3]; webpQuality = params[4]; mode = params[5]; GM_setValue("figmaToken", figmaToken); GM_setValue("svgOptimizerRequestUrl", svgOptimizerRequestUrl); GM_setValue("pngConvertToWebpRequestUrl", pngConvertToWebpRequestUrl); GM_setValue("svgPrecision", svgPrecision); GM_setValue("webpQuality", webpQuality); GM_setValue("mode", mode); }); } // 默认配置 let figmaToken = GM_getValue("figmaToken", ""); let svgOptimizerRequestUrl = GM_getValue("svgOptimizerRequestUrl", ""); let pngConvertToWebpRequestUrl = GM_getValue("pngConvertToWebpRequestUrl", ""); // svg 专用 let svgPrecision = GM_getValue("svgPrecision", 1); // png 专用 // webp 转换质量,0-100,默认 75 let webpQuality = GM_getValue("webpQuality", 75); // 是否暗色模式 let mode = GM_getValue("mode", "day"); class Image { /** * @type {string} */ url; /** * @type {Blob} 从 figma 下载的原始图层内容,可能是 svg,也有可能是 png */ originalContent; /** * @type {number} */ scale; /** * @type {Blob} 经处理后的数据,可能是优化后的 svg,也有可能是经 png 转换过后的 webp */ processedContent; /** * @type {string} 最终创建文件的格式/后缀名 */ format; /** * @type {Blob} 最终存储到文件的数据 */ finalContent; /** * @param id {string} * @param name {string} */ constructor(id, name) { this.id = id; this.name = name; } } function dirNameToScaleMap() { if (mode === "day") { return _dirNameToScaleMapDay; } else { return _dirNameToScaleMapNight; } } function scaleToDirNameMap() { if (mode === "day") { return _scaleToDirNameMapDay; } else { return _scaleToDirNameMapNight; } } const _dirNameToScaleMapDay = new Map(); _dirNameToScaleMapDay.set("drawable-ldpi", 0.75); _dirNameToScaleMapDay.set("drawable-mdpi", 1); _dirNameToScaleMapDay.set("drawable-hdpi", 1.5); _dirNameToScaleMapDay.set("drawable-xhdpi", 2); _dirNameToScaleMapDay.set("drawable-xxhdpi", 3); _dirNameToScaleMapDay.set("drawable-xxxhdpi", 4); const _scaleToDirNameMapDay = new Map(); _scaleToDirNameMapDay.set(0.75, "drawable-ldpi"); _scaleToDirNameMapDay.set(1, "drawable-mdpi"); _scaleToDirNameMapDay.set(1.5, "drawable-hdpi"); _scaleToDirNameMapDay.set(2, "drawable-xhdpi"); _scaleToDirNameMapDay.set(3, "drawable-xxhdpi"); _scaleToDirNameMapDay.set(4, "drawable-xxxhdpi"); const _dirNameToScaleMapNight = new Map(); _dirNameToScaleMapNight.set("drawable-night-ldpi", 0.75); _dirNameToScaleMapNight.set("drawable-night-mdpi", 1); _dirNameToScaleMapNight.set("drawable-night-hdpi", 1.5); _dirNameToScaleMapNight.set("drawable-night-xhdpi", 2); _dirNameToScaleMapNight.set("drawable-night-xxhdpi", 3); _dirNameToScaleMapNight.set("drawable-night-xxxhdpi", 4); const _scaleToDirNameMapNight = new Map(); _scaleToDirNameMapNight.set(0.75, "drawable-night-ldpi"); _scaleToDirNameMapNight.set(1, "drawable-night-mdpi"); _scaleToDirNameMapNight.set(1.5, "drawable-night-hdpi"); _scaleToDirNameMapNight.set(2, "drawable-night-xhdpi"); _scaleToDirNameMapNight.set(3, "drawable-night-xxhdpi"); _scaleToDirNameMapNight.set(4, "drawable-night-xxxhdpi"); const svgButtonId = "svgo-button"; const svgoButton = document.createElement("button"); svgoButton.id = svgButtonId; svgoButton.className = "basic_form--btn--t7Y67 ellipsis--ellipsis--70pHK text--fontPos11--rO47d text--_fontBase--VaHfk button_row--btnNarrow--bKZDj button_row--btn--0W3Mm basic_form--btn--t7Y67 ellipsis--ellipsis--70pHK text--fontPos11--rO47d text--_fontBase--VaHfk"; svgoButton.style.marginTop = "16px"; svgoButton.style.width = "90%"; svgoButton.style.marginLeft = "auto"; svgoButton.style.marginRight = "auto"; svgoButton.innerText = "经 SVGO 优化并导出"; svgoButton.addEventListener("click", function () { onClickDownloadSvg().then(); }); const pngButtonId = "png-button"; const pngButton = document.createElement("button"); pngButton.id = pngButtonId; pngButton.className = "basic_form--btn--t7Y67 ellipsis--ellipsis--70pHK text--fontPos11--rO47d text--_fontBase--VaHfk button_row--btnNarrow--bKZDj button_row--btn--0W3Mm basic_form--btn--t7Y67 ellipsis--ellipsis--70pHK text--fontPos11--rO47d text--_fontBase--VaHfk"; pngButton.style.marginTop = "16px"; pngButton.style.width = "90%"; pngButton.style.marginLeft = "auto"; pngButton.style.marginRight = "auto"; pngButton.innerText = "导出 PNG 到指定 res 目录"; pngButton.addEventListener("click", function () { onClickDownloadPng(false).then(); }); const webpButtonId = "webp-button"; const webpButton = document.createElement("button"); webpButton.id = webpButtonId; webpButton.className = "basic_form--btn--t7Y67 ellipsis--ellipsis--70pHK text--fontPos11--rO47d text--_fontBase--VaHfk button_row--btnNarrow--bKZDj button_row--btn--0W3Mm basic_form--btn--t7Y67 ellipsis--ellipsis--70pHK text--fontPos11--rO47d text--_fontBase--VaHfk"; webpButton.style.width = "90%"; webpButton.style.marginTop = "16px"; webpButton.style.marginLeft = "auto"; webpButton.style.marginRight = "auto"; webpButton.innerText = "导出 WebP 到指定 res 目录"; webpButton.addEventListener("click", function () { onClickDownloadPng(true).then(); }); // 监听 body 元素变动,根据情况插入导出按钮(对于无编辑器权限的使用者) new MutationObserver(() => { let c = null; const anchorElemForGuest = document.querySelector("div.raw_components--panel--YDedw.export_panel--standalonePanel--yXYPM"); if (anchorElemForGuest !== null) { c = anchorElemForGuest.parentElement; } else { const nodeList = document.querySelectorAll("div.draggable_list--panelTitleText--Bj2Hu") const anchorElemForOwner = Array.from(nodeList).find(node => node.innerText === "Export") if (anchorElemForOwner !== null) { c = anchorElemForOwner.parentElement.parentElement.parentElement.parentElement.parentElement } } if (c !== null) { if (document.getElementById(svgButtonId) === null) { c.appendChild(svgoButton); } if (document.getElementById(pngButtonId) === null) { c.appendChild(pngButton); } if (document.getElementById(webpButtonId) === null) { c.appendChild(webpButton); } } }).observe(document.body, {childList: true, subtree: true}); // (对于有编辑权限的使用者) const Toast = Swal.mixin({ position: "center", allowOutsideClick: false }); // SVGO 优化下载功能 START async function onClickDownloadSvg() { const layerList = getSelectedLayerList(); if (layerList.length === 0) { showError("未选择图层"); return; } const fileKey = figma.fileKey; const dirHandle = await unsafeWindow.showDirectoryPicker({id: `${fileKey}-svg`, mode: "readwrite"}); showExporting(); try { const finalImageList = await downloadSelectedLayerAsSvg(dirHandle, fileKey, layerList); const successText = getSuccessText(finalImageList); showSuccess(successText); } catch (e) { console.error(e); showError(e.toString()); } } /** * 将选中的图层下载为经 svgo 优化过后的 svg 图像,保存到指定地址 * @async * @param dirHandle {FileSystemDirectoryHandle} 文件操作 Handle * @param fileKey {string} figma 文件 key * @param layerList {Image[]} 图层信息,格式为 [{"id": "svg id", "name": "svg name"}] * @return {Promise} */ async function downloadSelectedLayerAsSvg(dirHandle, fileKey, layerList) { let optimizedImageList; // 1. 下载源 svg const imageList = await downloadImageFromFigma(fileKey, layerList, "svg", 1); if (imageList === undefined || imageList.length === 0) { throw new Error("从 figma 获取图片失败,请检查网络连接"); } // 任何一张图层未下载成功,都判定整体失败 if (!imageList.every(image => image.originalContent !== undefined)) { throw new Error("从 figma 下载图片内容失败,请检查网络连接"); } // 2. 经 svgo 优化 optimizedImageList = await optimizeSvg(imageList, svgPrecision); // 3. 保存到指定文件 optimizedImageList.forEach(image => image.finalContent = image.processedContent); await saveImageWithDifferentDpiToDir(dirHandle, optimizedImageList); return optimizedImageList; } /** * * @param imageList {Image[]} * @param precision {number} * @returns */ async function optimizeSvg(imageList, precision) { try { const svgContentList = await Promise.all(imageList.map(image => image.originalContent.text())); const requestBody = { precision: precision, svgContentList: svgContentList }; const response = await fetch(getSvgOptimizerRequestUrl(), { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(requestBody) }); const responseJson = await response.json(); imageList.forEach((image, index) => { image.processedContent = new Blob([responseJson[index]], { type: image.originalContent.type }); }); return imageList; } catch (e) { console.error(e); throw new e; } } // SVGO 优化下载功能 END // PNG 下载及转换功能 START async function onClickDownloadPng(convertToWebp) { const layerList = getSelectedLayerList(); if (layerList.length === 0) { showError("未选择图层"); return; } const fileKey = figma.fileKey; let dirHandleId; if (convertToWebp) { dirHandleId = `${fileKey}-webp`; } else { dirHandleId = `${fileKey}-png`; } const dirHandle = await unsafeWindow.showDirectoryPicker({id: dirHandleId, mode: "readwrite"}); showExporting(); const scaleList = await getScaleList(dirHandle); if (scaleList.length === 0) { showError("所选目录下需要有指定 dpi 的\"drawable-*dpi\"的文件夹"); return; } try { const finalImageList = await exportPng(convertToWebp, dirHandle, fileKey, layerList, scaleList); let successText; if (!finalImageList.every(image => image.format === "png")) { // 表示有导出为 webp 的文件 successText = getSuccessText(finalImageList); } showSuccess(successText); } catch (e) { console.error(e); showError(e.toString()); } } /** * * @param {boolean} convertToWebp 是否需要转换成 webp * @param {FileSystemDirectoryHandle} dirHandle * @param {string} fileKey figma 对应的文件 key * @param {Image[]} layerList 需要导出的图层信息,包括 id 和 name * @param {number[]} scaleList dpi 对应的缩放倍率 * @returns {Promise} */ async function exportPng(convertToWebp, dirHandle, fileKey, layerList, scaleList) { let imageList = await downloadSelectedLayerAsPng(dirHandle, fileKey, layerList, scaleList); if (convertToWebp) { imageList = await transferPngListToWebp(imageList, webpQuality); imageList.forEach((image) => { // 只有在 webp 小于 png 时,才存储为 webp if (image.processedContent.size > image.originalContent.size) { image.format = "png"; image.finalContent = image.originalContent; } else { image.format = "webp"; image.finalContent = image.processedContent; } }); } else { imageList.forEach((image) => { image.format = "png"; image.finalContent = image.originalContent; }); } await saveImageWithDifferentDpiToDir(dirHandle, imageList); return imageList; } /** * 通过分析选中目录下的文件夹情况,得出需要下载的 dpi 对应的缩放倍率列表 * @param {FileSystemDirectoryHandle} dirHandle * @return {Promise} */ async function getScaleList(dirHandle) { const scaleList = []; for await (const file of dirHandle.values()) { if (file.kind === "directory") { const scale = dirNameToScaleMap().get(file.name); if (scale !== undefined) { scaleList.push(scale); } } } return scaleList; } /** * 将选中的图层根据给出的 scaleList 下载为 png * @param {FileSystemDirectoryHandle} dirHandle 文件操作 Handle * @param {string} fileKey figma 文件 key * @param {Image[]} layerList 图层信息,格式为 [{"id": "svg id", "name": "svg name"}] * @param {number[]} scaleList dpi 对应的缩放倍率 * @return {Promise} 从 figma 下载下来的图片内容 */ async function downloadSelectedLayerAsPng(dirHandle, fileKey, layerList, scaleList) { const imageGroupByScale = await Promise.all(scaleList.map(scale => downloadImageFromFigma(fileKey, layerList, "png", scale))); /** @type {Image[]} */ const imageList = imageGroupByScale.flat().filter(image => image !== undefined); if (imageList === undefined || imageList.length === 0) { throw new Error("从 figma 获取图片失败,请检查网络连接"); } // 任何一张图层未下载成功,都判定整体失败 if (!imageList.every(image => image.originalContent !== undefined)) { throw new Error("从 figma 下载图片内容失败,请检查网络连接"); } return imageList; } /** * 批量转换 png 为 webp * @param {Image[]} imageList * @param {number} quality 质量 * @return {Promise} 输出的值比参数 imageList 添加了 processedContent 属性 * * @throws {Error} 操作失败会抛出异常 */ async function transferPngListToWebp(imageList, quality) { try { const responseList = await Promise.all( imageList.map(image => { return fetch(getPngConvertToWebpRequestUrl(), { method: "POST", headers: { "Content-Type": "application/octet-stream", "quality": quality }, body: image.originalContent }); }) ); for (const image of imageList) { const index = imageList.indexOf(image); image.processedContent = await responseList[index].blob(); } return imageList; } catch (e) { console.error(e); throw new Error("png 转 webp 操作失败,请检查是否开启优化服务器"); } } // PNG 下载及转换功能 END // 公共能力 START /** * 获取当前选中的图层,包括 id 和 name * @return {[Image]} */ function getSelectedLayerList() { return figma.currentPage.selection.map(node => new Image(node.id, node.name.toLowerCase().replace(/[^a-z0-9_]/g, "_"))); } /** * 生成的一个随机四位数,并以下划线开头,作为文件的前缀,以防重名时覆盖已有文件 * @return {string} */ function getRandomPrefix() { return "figma" + Math.floor(Math.random() * 9000 + 1000); } /** * 下载选中图层的内容,包括内容指向 url 和具体的文件内容 * @async * @param {string} figmaFileKey * @param {string} format 格式 svg, png * @param {number} scale 缩放大小 * @param {Image[]} layerList 包含有 id 和 name 的图层信息列表 * @returns {Promise} 从 figma 下载下来的图片内容 */ async function downloadImageFromFigma(figmaFileKey, layerList, format, scale) { try { // 此处必须深拷贝 const imageList = layerList.map(layer => new Image(layer.id, layer.name)); const ids = imageList.map(image => image.id); let url = `https://api.figma.com/v1/images/${figmaFileKey}?ids=${ids.join(",")}&format=${format}&scale=${scale}`; const res = await fetch(url, { headers: { "X-FIGMA-TOKEN": figmaToken } } ); if (res.status !== 200) return undefined; const originalImageListJson = await res.json(); imageList.forEach(layer => { layer.url = originalImageListJson.images[layer.id]; layer.scale = scale; layer.format = format; }); // 下载 image 内容 const originalContentList = await Promise.all(imageList.map(image => downloadOriginalImageContent(image.url))); originalContentList.forEach((originalContent, index) => { imageList[index].originalContent = originalContent; }); return imageList; } catch (e) { console.error(e); } } /** * 下载给定的 url 的内容 * @async * @param url 资源目标 url * @returns {Promise} 下载下来的二进制内容 */ async function downloadOriginalImageContent(url) { try { let res = await fetch(url); if (res.status === 200) { // 需要用二进制数据 return await res.blob(); } else { console.log("错误?" + res.status); } } catch (e) { console.error(e); } } /** * 保存内容到文件 * @param {FileSystemDirectoryHandle} dirHandle * @param {Image[]} imageList */ async function saveImageWithDifferentDpiToDir(dirHandle, imageList) { const prefix = getRandomPrefix(); for (const image of imageList) { /** @type {FileSystemDirectoryHandle} */ let drawableDirHandle; if (image.format === "svg") { // svg 图片直接保存到目录下 drawableDirHandle = dirHandle; } else { // 其它图片需要保存到对应 dpi 的目录下 const drawableDirName = scaleToDirNameMap().get(image.scale); drawableDirHandle = await dirHandle.getDirectoryHandle(drawableDirName); } const fileHandle = await drawableDirHandle.getFileHandle(`${prefix}_${image.name}.${image.format}`, {create: true}); const writable = await fileHandle.createWritable(); await writable.write(image.finalContent); await writable.close(); } } /** * 格式化 bytes 数量为可读字符串 * @param {number} bytesSize * @return {string} */ function formatBytes(bytesSize) { if (bytesSize < 1024) { return bytesSize + " Bytes"; } else if (bytesSize < 1024 * 1024) { return (bytesSize / 1024).toFixed(2) + " KB"; } else { return (bytesSize / (1024 * 1024)).toFixed(2) + " MB"; } } /** * 获取成功提示文字,主要是关于体积缩减大小 * @param {Image[]} finalImageList * @return {string} */ function getSuccessText(finalImageList) { const originalSize = finalImageList.reduce((accumulator, currentValue) => { return accumulator + currentValue.originalContent.size; }, 0); const finalSize = finalImageList.reduce((accumulator, currentValue) => { return accumulator + currentValue.finalContent.size; }, 0); return `成功缩减体积 ${formatBytes(originalSize - finalSize)}(${((originalSize - finalSize) * 100 / originalSize).toFixed(0)}%)`; } function showExporting() { Toast.fire({ title: "图层导出中...", didOpen() { Swal.showLoading(); } }); } /** * @param {string} successText */ function showSuccess(successText) { Toast.fire({ icon: "success", title: "导出成功", text: successText }); } /** * @param {string} errorText */ function showError(errorText) { Toast.fire({ icon: "error", text: errorText, title: "导出失败,请重试", }); } function getSvgOptimizerRequestUrl() { if (svgOptimizerRequestUrl === "") { return "https://nifh3bnmc3.hk.aircode.run/svgOptimizer"; } else { return svgOptimizerRequestUrl; } } function getPngConvertToWebpRequestUrl() { if (pngConvertToWebpRequestUrl === "") { return "https://nifh3bnmc3.hk.aircode.run/webpConvetor"; } else { return pngConvertToWebpRequestUrl; } } // 公共能力 END })();